﻿using System;
using System.Configuration;
using System.Diagnostics;
using System.IO;
using System.Linq;
using MLSharp.Classification;
using MLSharp.Filters;
using MLSharp.Utilities;

namespace MLSharp.RProject.Amore
{
	/// <summary>
	/// Interface to the AMORE neural network library for R.
	/// </summary>
	public class AmoreNeuralNetworkFactory : IClassifierFactory
	{
		#region Logger

		/// <summary>
		/// The logger for the class.
		/// </summary>
		private static readonly log4net.ILog mLogger =
			log4net.LogManager.GetLogger(System.Reflection.MethodBase.GetCurrentMethod().DeclaringType);

		#endregion

		#region Private Fields

		/// <summary>
		/// The path to R.exe.
		/// </summary>
		private readonly string mRPath;

		/// <summary>
		/// The path to the working directory.
		/// </summary>
		private readonly string mWorkingDirectory;

		#endregion

		#region Public Properties

		/// <summary>
		/// The options to use when building neural networks.
		/// </summary>
		public AmoreNeuralNetOptions Options { get; private set; }

		/// <summary>
		/// Wrapper for Options.
		/// </summary>
		object IClassifierFactory.Options
		{
			get { return Options; }
		}

		#endregion

		#region Public Constructors

		/// <summary>
		/// Creates a factory.
		/// </summary>
		/// <remarks>
		/// You can configure R using the app.config file.  You can specify:
		/// <list>
		///		<item>RPath - the full path to R.exe.</item>
		///		<item>RWorkingDirectory - A directory to place intermediate output into.</item>
		/// </list>
		/// </remarks>
		public AmoreNeuralNetworkFactory()
		{
			mRPath = ConfigurationManager.AppSettings["RPath"] ?? Path.GetFullPath("R.exe");
			mWorkingDirectory = ConfigurationManager.AppSettings["RWorkingDirectory"] ?? Path.GetFullPath(@".\R");

			Initialize();
		}

		/// <summary>
		/// Creates a factory using the specified paths.
		/// </summary>
		/// <param name="rPath">The path to R.exe.</param>
		/// <param name="workingDirectory">The path to the working directory.</param>
		public AmoreNeuralNetworkFactory(string rPath, string workingDirectory)
		{
			mRPath = Path.GetFullPath(rPath);
			mWorkingDirectory = Path.GetFullPath(workingDirectory);

			Initialize();
		}

		#endregion

		#region Private Static Methods

		/// <summary>
		/// Gets a string representation for the specified cost function.
		/// </summary>
		/// <param name="costFunction"></param>
		/// <returns></returns>
		private static string GetErrorCriterium(CostFunction costFunction)
		{
			switch (costFunction)
			{
				case CostFunction.LeastMeanLogSquares:
					return "LMLS";
				case CostFunction.LeastMeanSquares:
					return "LMS";
				default:
					throw new NotSupportedException("Cost function is not supported.");
			}
		}

		/// <summary>
		/// Gets a representation of the activation function.
		/// </summary>
		/// <returns></returns>
		private static string GetActivationFunction(ActivationFunction activationFunction)
		{
			switch (activationFunction)
			{
				case ActivationFunction.HyperbolicTangent:
					return "tansig";
				case ActivationFunction.Purelinear:
					return "purelin";
				case ActivationFunction.Sigmoid:
					return "sigmoid";
				default:
					throw new NotSupportedException("Activation function is not supported yet.");
			}
		}

		/// <summary>
		/// Gets the string representation of the training method.
		/// </summary>
		/// <param name="batchMode">True if batch mode is being used.</param>
		/// <param name="momentum">The momentum.</param>
		/// <returns></returns>
		private static string GetTrainingMethod(bool batchMode, double momentum)
		{
			string mode = batchMode ? "BATCHgd" : "ADAPTgd";

			if (momentum != 0)
			{
				mode += "wm";
			}

			return mode;
		}

		#endregion

		#region Private Methods

		/// <summary>
		/// Performs common initialization.
		/// </summary>
		private void Initialize()
		{
			Options = new AmoreNeuralNetOptions();

			if (!Directory.Exists(mWorkingDirectory))
			{
				Directory.CreateDirectory(mWorkingDirectory);
			}

			ValidateConfiguration();
		}

		/// <summary>
		/// Verifies that fields have been set to correct, valid values.
		/// </summary>
		private void ValidateConfiguration()
		{
			//Make sure the R executable path is valid.
			if (!File.Exists(mRPath))
			{
				throw new FileNotFoundException(string.Format("Unable to locate R.exe at '{0}'", mRPath));
			}
		}

		/// <summary>
		/// Creates a string containing the number of neurons per layer formatted
		/// for consumption by R.
		/// </summary>
		/// <returns>A correctly formatted input string.</returns>
		private string GetNeuronCounts(int variableCount)
		{
			//Right now, only a single target variable is supported.
			int targetVariableCount = 1;

			//This holds the number of neurons that are in each hidden layer.
			int[] neuronCounts;

			if (Options.NeuronCounts.Length == 0)
			{
				//The default is one layer with 2x as many neurons as input features.
				neuronCounts = new [] { variableCount * 2};
			}
			else
			{
				neuronCounts = Options.NeuronCounts;
			}

			string[] counts = neuronCounts.Select(item => item.ToString()).ToArray();

			return string.Format("{0},{1},{2}", variableCount, string.Join(",", counts), targetVariableCount);
		}


		#endregion

		#region Public Methods

		/// <summary>
		/// Builds a neural network that can predict the specified target attribute.
		/// </summary>
		/// <param name="dataSet">The data set to train the network on.</param>
		/// <returns>The neural network classifier.</returns>
		/// <remarks>
		/// The network *must* include only attributes that can be converted to 
		/// doubles (label attributes can still be arbitrary strings).  You can
		/// use <see cref="StandardizeFilter"/> to standardize your data set.
		/// </remarks>
		public INeuralNetClassifier BuildNeuralNetwork(IDataSet dataSet)
		{
			//This is the actual R script that will be generated.
			string rScriptName = Path.Combine(mWorkingDirectory, NameGenerator.GetRandomName(10) + ".R");
			//And these are the input variables for the instances
			string rInputTableName = Path.Combine(mWorkingDirectory, NameGenerator.GetRandomName(10) + ".InpTable");
			//And these are the instance target/output variables
			string rTargetTableName = Path.Combine(mWorkingDirectory, NameGenerator.GetRandomName(10) + ".TarTable");

			//The number of independent attributes that are in the data set.
			int independentAttributeCount = dataSet.Attributes.Count - 1;

			using (StreamWriter writer = File.CreateText(rScriptName))
			{
				//Step 1: Write the header part of an R script.
				writer.WriteLine("library(AMORE)");

				//Step 2: Write the training data to a .table file
				RTableWriter.Write(dataSet, rInputTableName, rTargetTableName);

				//Step 3: Write the command to load the inputs table
				writer.WriteLine("x<-as.matrix(read.table(\"{0}\", header = FALSE))", rInputTableName.Replace(@"\", @"\\"));

				//Step 4: Write the command to load the outputs table
				writer.WriteLine("y<-as.matrix(read.table(\"{0}\", header = FALSE))", rTargetTableName.Replace(@"\", @"\\"));

				//This command will train a new network.
				string trainNetwork = @"net.start <- newff(n.neurons=c({0}),learning.rate.global={1},
momentum.global={2},
error.criterium=""{3}"",
Stao=NA,
hidden.layer=""{4}"",
output.layer=""{5}"",
method=""{6}"")

result <- train(net.start, x, y, error.criterium=""{3}"", report=TRUE, show.step={7}, n.shows=10 )
";

				trainNetwork = string.Format(trainNetwork, GetNeuronCounts(independentAttributeCount), Options.LearningRate,
				                             Options.Momentum, GetErrorCriterium(Options.CostFunction),
				                             GetActivationFunction(Options.HiddenLayerActivationFunction),
				                             GetActivationFunction(Options.OutputLayerActivationFunction),
				                             GetTrainingMethod(Options.BatchMode, Options.Momentum),
				                             Options.MaxIterations/10);

				if (mLogger.IsInfoEnabled)
				{
					mLogger.InfoFormat("Training network: {0}", trainNetwork);
				}

				//Step 5: Write the training command to the R script
				writer.WriteLine(trainNetwork);

				//Step 6: Write the commands to dump the output
				writer.WriteLine("dump(\"result\",file=stdout())");
			}

			//Step 7: Execute R on the script, redirecting the output
			ProcessStartInfo startInfo = new ProcessStartInfo(mRPath);
			startInfo.RedirectStandardOutput = true;
			startInfo.RedirectStandardError = true;
			startInfo.WorkingDirectory = mWorkingDirectory;
			startInfo.Arguments = string.Format("-f \"{0}\" --slave", rScriptName);
			//This must be false if you are redirecting input/output
			startInfo.UseShellExecute = false;
			startInfo.CreateNoWindow = true;

			string output;

			using (Process rProcess = Process.Start(startInfo))
			{
				if (rProcess == null)
				{
					throw new NullReferenceException("rProcess is null!");
				}

				output = rProcess.StandardOutput.ReadToEnd();
				rProcess.WaitForExit();
			}

			//Step 8: Parse the output and extract the network parameters.

			//There should be 10 lines of "report" messages that contain the error rates.
			for (int i=0; i < 10; i++)
			{
				string line = output.Substring(0, output.IndexOf("\r\n") + 2);

				if (mLogger.IsDebugEnabled)
				{
					mLogger.Debug(line);
				}

				output = output.Remove(0, line.Length);
			}

			//The output had better begin with a network assignment, or something has gone wrong
			if (!output.StartsWith("result <-"))
			{
				throw new InvalidOperationException("Unexpected output from R.exe, begins with '" +
				                                    output.Substring(0, Math.Min(10, output.Length)) + "'");
			}

			//The output should end with '))\r\n'
			if (!output.EndsWith("))\r\n"))
			{
				throw new InvalidOperationException("Unexpected output fro R.exe, ends with '" +
				                                    output.Substring(Math.Max(0, output.Length - 10)) + "'");
			}

			//Step 9: Cleanup the temp files.
			File.Delete(rScriptName);
			File.Delete(rInputTableName);
			File.Delete(rTargetTableName);

			//Step 10: Return a new network.
			return new AmoreNeuralNetwork(mWorkingDirectory, mRPath, output);
		}

		#endregion

		#region IClassifierFactory Members

		/// <summary>
		/// Calls <see cref="BuildNeuralNetwork"/>.
		/// </summary>
		/// <param name="trainingData"></param>
		/// <returns></returns>
		IClassifier IClassifierFactory.BuildClassifier(IDataSet trainingData)
		{
			return BuildNeuralNetwork(trainingData);
		}

		#endregion
	}
}
